Нема описа

[id].tsx 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. import { useEffect, useMemo, useState } from 'react';
  2. import {
  3. Alert,
  4. Image,
  5. KeyboardAvoidingView,
  6. Modal,
  7. Platform,
  8. Pressable,
  9. ScrollView,
  10. StyleSheet,
  11. TextInput,
  12. View,
  13. } from 'react-native';
  14. import * as ImagePicker from 'expo-image-picker';
  15. import { ResizeMode, Video } from 'expo-av';
  16. import { useLocalSearchParams, useRouter } from 'expo-router';
  17. import { ThemedButton } from '@/components/themed-button';
  18. import { IconButton } from '@/components/icon-button';
  19. import { ThemedText } from '@/components/themed-text';
  20. import { ThemedView } from '@/components/themed-view';
  21. import { ZoomImageModal } from '@/components/zoom-image-modal';
  22. import { Colors } from '@/constants/theme';
  23. import { useColorScheme } from '@/hooks/use-color-scheme';
  24. import { useTranslation } from '@/localization/i18n';
  25. import { dbPromise, initCoreTables } from '@/services/db';
  26. type FieldRow = {
  27. id: number;
  28. name: string | null;
  29. };
  30. type CropRow = {
  31. id: number;
  32. crop_name: string | null;
  33. };
  34. type ObservationRow = {
  35. id: number;
  36. field_id: number | null;
  37. crop_id: number | null;
  38. obs_type: string | null;
  39. note: string | null;
  40. severity: number | null;
  41. observed_at: string | null;
  42. };
  43. type ImageRow = {
  44. uri: string | null;
  45. };
  46. export default function ObservationDetailScreen() {
  47. const { t } = useTranslation();
  48. const router = useRouter();
  49. const { id } = useLocalSearchParams<{ id?: string | string[] }>();
  50. const observationId = Number(Array.isArray(id) ? id[0] : id);
  51. const theme = useColorScheme() ?? 'light';
  52. const palette = Colors[theme];
  53. const [loading, setLoading] = useState(true);
  54. const [status, setStatus] = useState('');
  55. const [fields, setFields] = useState<FieldRow[]>([]);
  56. const [crops, setCrops] = useState<CropRow[]>([]);
  57. const [fieldModalOpen, setFieldModalOpen] = useState(false);
  58. const [cropModalOpen, setCropModalOpen] = useState(false);
  59. const [selectedFieldId, setSelectedFieldId] = useState<number | null>(null);
  60. const [selectedCropId, setSelectedCropId] = useState<number | null>(null);
  61. const [type, setType] = useState('');
  62. const [severity, setSeverity] = useState('');
  63. const [note, setNote] = useState('');
  64. const [mediaUris, setMediaUris] = useState<string[]>([]);
  65. const [activeUri, setActiveUri] = useState<string | null>(null);
  66. const [errors, setErrors] = useState<{ field?: string; severity?: string }>({});
  67. const [zoomUri, setZoomUri] = useState<string | null>(null);
  68. const [saving, setSaving] = useState(false);
  69. const [showSaved, setShowSaved] = useState(false);
  70. useEffect(() => {
  71. let isActive = true;
  72. async function loadObservation() {
  73. try {
  74. await initCoreTables();
  75. const db = await dbPromise;
  76. const fieldRows = await db.getAllAsync<FieldRow>('SELECT id, name FROM fields ORDER BY name ASC;');
  77. const cropRows = await db.getAllAsync<CropRow>('SELECT id, crop_name FROM crops ORDER BY crop_name ASC;');
  78. const obsRows = await db.getAllAsync<ObservationRow>(
  79. 'SELECT id, field_id, crop_id, obs_type, note, severity, observed_at FROM observations WHERE id = ? LIMIT 1;',
  80. observationId
  81. );
  82. if (!isActive) return;
  83. setFields(fieldRows);
  84. setCrops(cropRows);
  85. const obs = obsRows[0];
  86. if (!obs) {
  87. setStatus(t('observations.empty'));
  88. setLoading(false);
  89. return;
  90. }
  91. setSelectedFieldId(obs.field_id ?? null);
  92. setSelectedCropId(obs.crop_id ?? null);
  93. setType(obs.obs_type ?? '');
  94. setSeverity(obs.severity !== null ? String(obs.severity) : '');
  95. setNote(obs.note ?? '');
  96. const imageRows = await db.getAllAsync<ImageRow>(
  97. 'SELECT uri FROM images WHERE observation_id = ? ORDER BY created_at ASC;',
  98. observationId
  99. );
  100. const media = uniqueMediaUris(imageRows.map((row) => row.uri).filter(Boolean) as string[]);
  101. setMediaUris(media);
  102. setActiveUri(media[0] ?? null);
  103. } catch (error) {
  104. if (isActive) setStatus(`Error: ${String(error)}`);
  105. } finally {
  106. if (isActive) setLoading(false);
  107. }
  108. }
  109. loadObservation();
  110. return () => {
  111. isActive = false;
  112. };
  113. }, [observationId, t]);
  114. const selectedField = useMemo(
  115. () => fields.find((item) => item.id === selectedFieldId),
  116. [fields, selectedFieldId]
  117. );
  118. const selectedCrop = useMemo(
  119. () => crops.find((item) => item.id === selectedCropId),
  120. [crops, selectedCropId]
  121. );
  122. const inputStyle = [
  123. styles.input,
  124. {
  125. borderColor: palette.border,
  126. backgroundColor: palette.input,
  127. color: palette.text,
  128. },
  129. ];
  130. const typePresets = ['scouting', 'pest', 'disease', 'weeds', 'nutrients', 'irrigation'];
  131. async function handleUpdate() {
  132. const parsedSeverity = severity.trim() ? Number(severity) : null;
  133. const nextErrors: { field?: string; severity?: string } = {};
  134. if (!selectedFieldId) {
  135. nextErrors.field = t('observations.fieldRequired');
  136. }
  137. if (severity.trim() && !Number.isFinite(parsedSeverity)) {
  138. nextErrors.severity = t('observations.severityInvalid');
  139. }
  140. setErrors(nextErrors);
  141. if (Object.keys(nextErrors).length > 0) return;
  142. try {
  143. setSaving(true);
  144. const db = await dbPromise;
  145. await db.runAsync(
  146. 'UPDATE observations SET field_id = ?, crop_id = ?, obs_type = ?, note = ?, severity = ? WHERE id = ?;',
  147. selectedFieldId,
  148. selectedCropId,
  149. type.trim() || null,
  150. note.trim() || null,
  151. parsedSeverity,
  152. observationId
  153. );
  154. await db.runAsync('DELETE FROM images WHERE observation_id = ?;', observationId);
  155. const now = new Date().toISOString();
  156. for (const uri of uniqueMediaUris(mediaUris)) {
  157. await db.runAsync(
  158. 'INSERT INTO images (observation_id, uri, created_at) VALUES (?, ?, ?);',
  159. observationId,
  160. uri,
  161. now
  162. );
  163. }
  164. setStatus(t('observations.saved'));
  165. setShowSaved(true);
  166. setTimeout(() => {
  167. setShowSaved(false);
  168. setStatus('');
  169. }, 1800);
  170. } catch (error) {
  171. setStatus(`Error: ${String(error)}`);
  172. } finally {
  173. setSaving(false);
  174. }
  175. }
  176. function confirmDelete() {
  177. Alert.alert(
  178. t('observations.deleteTitle'),
  179. t('observations.deleteMessage'),
  180. [
  181. { text: t('observations.cancel'), style: 'cancel' },
  182. {
  183. text: t('observations.delete'),
  184. style: 'destructive',
  185. onPress: async () => {
  186. const db = await dbPromise;
  187. await db.runAsync('DELETE FROM images WHERE observation_id = ?;', observationId);
  188. await db.runAsync('DELETE FROM observations WHERE id = ?;', observationId);
  189. router.back();
  190. },
  191. },
  192. ]
  193. );
  194. }
  195. if (loading) {
  196. return (
  197. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  198. <ThemedText>{t('observations.loading')}</ThemedText>
  199. </ThemedView>
  200. );
  201. }
  202. return (
  203. <ThemedView style={[styles.container, { backgroundColor: palette.background }]}>
  204. <KeyboardAvoidingView
  205. behavior={Platform.OS === 'ios' ? 'padding' : undefined}
  206. style={styles.keyboardAvoid}>
  207. <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
  208. <ThemedText type="title">{t('observations.edit')}</ThemedText>
  209. {status && !showSaved ? <ThemedText>{status}</ThemedText> : null}
  210. <ThemedText>{t('observations.field')}</ThemedText>
  211. <ThemedButton
  212. title={selectedField?.name || t('observations.selectField')}
  213. onPress={() => setFieldModalOpen(true)}
  214. variant="secondary"
  215. />
  216. {errors.field ? <ThemedText style={styles.errorText}>{errors.field}</ThemedText> : null}
  217. <ThemedText>{t('observations.crop')}</ThemedText>
  218. <ThemedButton
  219. title={selectedCrop?.crop_name || t('observations.selectCrop')}
  220. onPress={() => setCropModalOpen(true)}
  221. variant="secondary"
  222. />
  223. <ThemedText>{t('observations.type')}</ThemedText>
  224. <View style={styles.chipRow}>
  225. {typePresets.map((preset) => {
  226. const label = t(`observations.type.${preset}`);
  227. const isActive = type === label || type === preset;
  228. return (
  229. <Pressable
  230. key={preset}
  231. style={[styles.chip, isActive ? styles.chipActive : null]}
  232. onPress={() => setType(label)}>
  233. <ThemedText style={styles.chipText}>{label}</ThemedText>
  234. </Pressable>
  235. );
  236. })}
  237. </View>
  238. <TextInput
  239. value={type}
  240. onChangeText={setType}
  241. placeholder={t('observations.typePlaceholder')}
  242. placeholderTextColor={palette.placeholder}
  243. style={inputStyle}
  244. />
  245. <ThemedText>{t('observations.severity')}</ThemedText>
  246. <TextInput
  247. value={severity}
  248. onChangeText={(value) => {
  249. setSeverity(value);
  250. if (errors.severity) setErrors((prev) => ({ ...prev, severity: undefined }));
  251. }}
  252. placeholder={t('observations.severityPlaceholder')}
  253. placeholderTextColor={palette.placeholder}
  254. style={inputStyle}
  255. keyboardType="decimal-pad"
  256. />
  257. {errors.severity ? <ThemedText style={styles.errorText}>{errors.severity}</ThemedText> : null}
  258. <ThemedText>{t('observations.note')}</ThemedText>
  259. <TextInput
  260. value={note}
  261. onChangeText={setNote}
  262. placeholder={t('observations.notePlaceholder')}
  263. placeholderTextColor={palette.placeholder}
  264. style={inputStyle}
  265. multiline
  266. />
  267. <ThemedText>{t('observations.addMedia')}</ThemedText>
  268. {normalizeMediaUri(activeUri) ? (
  269. isVideoUri(normalizeMediaUri(activeUri) as string) ? (
  270. <Video
  271. source={{ uri: normalizeMediaUri(activeUri) as string }}
  272. style={styles.mediaPreview}
  273. useNativeControls
  274. resizeMode={ResizeMode.CONTAIN}
  275. />
  276. ) : (
  277. <Pressable onPress={() => setZoomUri(normalizeMediaUri(activeUri) as string)}>
  278. <Image source={{ uri: normalizeMediaUri(activeUri) as string }} style={styles.mediaPreview} resizeMode="contain" />
  279. </Pressable>
  280. )
  281. ) : (
  282. <ThemedText style={styles.photoPlaceholder}>{t('observations.noPhoto')}</ThemedText>
  283. )}
  284. {mediaUris.length > 0 ? (
  285. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.mediaStrip}>
  286. {mediaUris.map((uri) => (
  287. <Pressable key={uri} style={styles.mediaChip} onPress={() => setActiveUri(uri)}>
  288. {isVideoUri(uri) ? (
  289. <View style={styles.videoThumb}>
  290. <ThemedText style={styles.videoThumbText}>▶</ThemedText>
  291. </View>
  292. ) : (
  293. <Image source={{ uri }} style={styles.mediaThumb} resizeMode="cover" />
  294. )}
  295. <Pressable
  296. style={styles.mediaRemove}
  297. onPress={(event) => {
  298. event.stopPropagation();
  299. setMediaUris((prev) => {
  300. const next = prev.filter((item) => item !== uri);
  301. setActiveUri((current) => (current === uri ? next[0] ?? null : current));
  302. return next;
  303. });
  304. }}>
  305. <ThemedText style={styles.mediaRemoveText}>×</ThemedText>
  306. </Pressable>
  307. </Pressable>
  308. ))}
  309. </ScrollView>
  310. ) : null}
  311. <View style={styles.photoRow}>
  312. <ThemedButton
  313. title={t('observations.pickFromGallery')}
  314. onPress={() =>
  315. handlePickMedia((uris) => {
  316. if (uris.length === 0) return;
  317. setMediaUris((prev) => uniqueMediaUris([...prev, ...uris]));
  318. setActiveUri((prev) => prev ?? uris[0]);
  319. })
  320. }
  321. variant="secondary"
  322. />
  323. <ThemedButton
  324. title={t('observations.takeMedia')}
  325. onPress={() =>
  326. handleTakeMedia((uri) => {
  327. if (!uri) return;
  328. setMediaUris((prev) => uniqueMediaUris([...prev, uri]));
  329. setActiveUri((prev) => prev ?? uri);
  330. })
  331. }
  332. variant="secondary"
  333. />
  334. </View>
  335. <View style={styles.actions}>
  336. <IconButton
  337. name="trash"
  338. onPress={confirmDelete}
  339. accessibilityLabel={t('observations.delete')}
  340. variant="danger"
  341. />
  342. <View style={styles.updateGroup}>
  343. {showSaved ? <ThemedText style={[styles.inlineToastText, { color: palette.success }]}>{t('observations.saved')}</ThemedText> : null}
  344. <ThemedButton
  345. title={saving ? t('observations.saving') : t('observations.update')}
  346. onPress={handleUpdate}
  347. disabled={saving}
  348. />
  349. </View>
  350. </View>
  351. </ScrollView>
  352. </KeyboardAvoidingView>
  353. <Modal transparent visible={fieldModalOpen} animationType="fade">
  354. <Pressable style={styles.modalBackdrop} onPress={() => setFieldModalOpen(false)}>
  355. <View style={styles.modalCard}>
  356. <ThemedText type="subtitle">{t('observations.selectField')}</ThemedText>
  357. <ScrollView style={styles.modalList}>
  358. {fields.map((item) => (
  359. <Pressable
  360. key={item.id}
  361. style={styles.modalItem}
  362. onPress={() => {
  363. setSelectedFieldId(item.id);
  364. setFieldModalOpen(false);
  365. }}>
  366. <ThemedText>{item.name || t('observations.untitled')}</ThemedText>
  367. </Pressable>
  368. ))}
  369. </ScrollView>
  370. </View>
  371. </Pressable>
  372. </Modal>
  373. <Modal transparent visible={cropModalOpen} animationType="fade">
  374. <Pressable style={styles.modalBackdrop} onPress={() => setCropModalOpen(false)}>
  375. <View style={styles.modalCard}>
  376. <ThemedText type="subtitle">{t('observations.selectCrop')}</ThemedText>
  377. <ScrollView style={styles.modalList}>
  378. {crops.map((item) => (
  379. <Pressable
  380. key={item.id}
  381. style={styles.modalItem}
  382. onPress={() => {
  383. setSelectedCropId(item.id);
  384. setCropModalOpen(false);
  385. }}>
  386. <ThemedText>{item.crop_name || t('observations.untitled')}</ThemedText>
  387. </Pressable>
  388. ))}
  389. </ScrollView>
  390. </View>
  391. </Pressable>
  392. </Modal>
  393. <ZoomImageModal uri={zoomUri} visible={Boolean(zoomUri)} onClose={() => setZoomUri(null)} />
  394. </ThemedView>
  395. );
  396. }
  397. async function handlePickMedia(onAdd: (uris: string[]) => void) {
  398. const result = await ImagePicker.launchImageLibraryAsync({
  399. mediaTypes: getMediaTypes(),
  400. quality: 1,
  401. allowsMultipleSelection: true,
  402. selectionLimit: 0,
  403. });
  404. if (result.canceled) return;
  405. const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[];
  406. if (uris.length === 0) return;
  407. onAdd(uris);
  408. }
  409. async function handleTakeMedia(onAdd: (uri: string | null) => void) {
  410. const permission = await ImagePicker.requestCameraPermissionsAsync();
  411. if (!permission.granted) {
  412. return;
  413. }
  414. const result = await ImagePicker.launchCameraAsync({
  415. mediaTypes: getMediaTypes(),
  416. quality: 1,
  417. });
  418. if (result.canceled) return;
  419. const asset = result.assets[0];
  420. onAdd(asset.uri);
  421. }
  422. function getMediaTypes() {
  423. const mediaType = (ImagePicker as {
  424. MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown };
  425. }).MediaType;
  426. const imageType = mediaType?.Image ?? mediaType?.Images;
  427. const videoType = mediaType?.Video ?? mediaType?.Videos;
  428. if (imageType && videoType) {
  429. return [imageType, videoType];
  430. }
  431. return imageType ?? videoType ?? ['images', 'videos'];
  432. }
  433. function isVideoUri(uri: string) {
  434. return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri);
  435. }
  436. function normalizeMediaUri(uri?: string | null) {
  437. if (typeof uri !== 'string') return null;
  438. const trimmed = uri.trim();
  439. return trimmed ? trimmed : null;
  440. }
  441. function uniqueMediaUris(uris: string[]) {
  442. const seen = new Set<string>();
  443. const result: string[] = [];
  444. for (const uri of uris) {
  445. if (!uri || seen.has(uri)) continue;
  446. seen.add(uri);
  447. result.push(uri);
  448. }
  449. return result;
  450. }
  451. const styles = StyleSheet.create({
  452. container: {
  453. flex: 1,
  454. },
  455. keyboardAvoid: {
  456. flex: 1,
  457. },
  458. content: {
  459. padding: 16,
  460. gap: 10,
  461. paddingBottom: 40,
  462. },
  463. input: {
  464. borderRadius: 10,
  465. borderWidth: 1,
  466. paddingHorizontal: 12,
  467. paddingVertical: 10,
  468. fontSize: 15,
  469. },
  470. errorText: {
  471. color: '#C0392B',
  472. fontSize: 12,
  473. },
  474. mediaPreview: {
  475. width: '100%',
  476. height: 220,
  477. borderRadius: 12,
  478. backgroundColor: '#1C1C1C',
  479. },
  480. photoRow: {
  481. flexDirection: 'row',
  482. gap: 8,
  483. },
  484. actions: {
  485. marginTop: 12,
  486. flexDirection: 'row',
  487. justifyContent: 'space-between',
  488. alignItems: 'center',
  489. gap: 10,
  490. },
  491. photoPlaceholder: {
  492. opacity: 0.6,
  493. },
  494. mediaStrip: {
  495. marginTop: 6,
  496. },
  497. mediaChip: {
  498. width: 72,
  499. height: 72,
  500. borderRadius: 10,
  501. marginRight: 8,
  502. overflow: 'hidden',
  503. backgroundColor: '#E6E1D4',
  504. alignItems: 'center',
  505. justifyContent: 'center',
  506. },
  507. mediaThumb: {
  508. width: '100%',
  509. height: '100%',
  510. },
  511. videoThumb: {
  512. width: '100%',
  513. height: '100%',
  514. backgroundColor: '#1C1C1C',
  515. alignItems: 'center',
  516. justifyContent: 'center',
  517. },
  518. videoThumbText: {
  519. color: '#FFFFFF',
  520. fontSize: 18,
  521. fontWeight: '700',
  522. },
  523. mediaRemove: {
  524. position: 'absolute',
  525. top: 4,
  526. right: 4,
  527. width: 18,
  528. height: 18,
  529. borderRadius: 9,
  530. backgroundColor: 'rgba(0,0,0,0.6)',
  531. alignItems: 'center',
  532. justifyContent: 'center',
  533. },
  534. mediaRemoveText: {
  535. color: '#FFFFFF',
  536. fontSize: 12,
  537. lineHeight: 14,
  538. fontWeight: '700',
  539. },
  540. updateGroup: {
  541. flexDirection: 'row',
  542. alignItems: 'center',
  543. gap: 8,
  544. },
  545. inlineToastText: {
  546. fontWeight: '700',
  547. fontSize: 12,
  548. },
  549. chipRow: {
  550. flexDirection: 'row',
  551. flexWrap: 'wrap',
  552. gap: 8,
  553. marginBottom: 8,
  554. },
  555. chip: {
  556. paddingHorizontal: 12,
  557. paddingVertical: 6,
  558. borderRadius: 999,
  559. borderWidth: 1,
  560. borderColor: '#D9D1C2',
  561. backgroundColor: '#F8F6F0',
  562. },
  563. chipActive: {
  564. backgroundColor: '#DDE8DA',
  565. borderColor: '#88A68F',
  566. },
  567. chipText: {
  568. fontSize: 13,
  569. },
  570. modalBackdrop: {
  571. flex: 1,
  572. backgroundColor: 'rgba(0,0,0,0.4)',
  573. justifyContent: 'center',
  574. padding: 24,
  575. },
  576. modalCard: {
  577. borderRadius: 14,
  578. backgroundColor: '#FFFFFF',
  579. padding: 16,
  580. gap: 10,
  581. maxHeight: '80%',
  582. },
  583. modalList: {
  584. maxHeight: 300,
  585. },
  586. modalItem: {
  587. paddingVertical: 10,
  588. },
  589. });